# import re
# import io
# import contextlib
# import traceback
# from pptx import Presentation
# from pptx.enum.shapes import MSO_SHAPE_TYPE, MSO_SHAPE, MSO_AUTO_SHAPE_TYPE
# from pptx.util import Inches, Pt
# from pptx.dml.color import RGBColor
# from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from camel.types import ModelPlatformType, ModelType
from camel.configs import ChatGPTConfig, QwenConfig, VLLMConfig, OpenRouterConfig, GeminiConfig
import math
# from urllib.parse import quote_from_bytes, quote
# from PIL import Image
# import os
# import copy
# import io
# from utils.src.utils import ppt_to_images
# from playwright.sync_api import sync_playwright
# from pathlib import Path
# from playwright.async_api import async_playwright
# import asyncio
# from utils.pptx_utils import *
# from utils.critic_utils import *

def get_agent_config(model_type):
    agent_config = {}
    if model_type == 'qwen':
        agent_config = {
            "model_type": ModelType.DEEPINFRA_QWEN_2_5_72B,
            "model_config": QwenConfig().as_dict(),
            "model_platform": ModelPlatformType.DEEPINFRA,
        }
    elif model_type == 'gemini':
        agent_config = {
            "model_type": ModelType.DEEPINFRA_GEMINI_2_FLASH,
            "model_config": GeminiConfig().as_dict(),
            "model_platform": ModelPlatformType.DEEPINFRA,
            'max_images': 99
        }
    elif model_type == 'phi4':
        agent_config = {
            "model_type": ModelType.DEEPINFRA_PHI_4_MULTIMODAL,
            "model_config": QwenConfig().as_dict(),
            "model_platform": ModelPlatformType.DEEPINFRA,
        }
    elif model_type == 'llama-4-scout-17b-16e-instruct':
        agent_config = {
            'model_type': ModelType.ALIYUN_LLAMA4_SCOUT_17B_16E,
            'model_config': QwenConfig().as_dict(),
            'model_platform': ModelPlatformType.QWEN,
            'max_images': 99
        }
    elif model_type == 'qwen-2.5-vl-72b':
        agent_config = {
            'model_type': ModelType.QWEN_2_5_VL_72B,
            'model_config': QwenConfig().as_dict(),
            'model_platform': ModelPlatformType.QWEN,
            'max_images': 99
        }
    elif model_type == 'gemma':
        agent_config = {
            "model_type": "google/gemma-3-4b-it",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:5555/v1',
            'max_images': 99
        }
    elif model_type == 'llava':
        agent_config = {
            "model_type": "llava-hf/llava-onevision-qwen2-7b-ov-hf",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:8000/v1',
            'max_images': 99
        }
    elif model_type == 'molmo-o':
        agent_config = {
            "model_type": "allenai/Molmo-7B-O-0924",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:8000/v1',
            'max_images': 99
        }
    elif model_type == 'qwen-2-vl-7b':
        agent_config = {
            "model_type": "Qwen/Qwen2-VL-7B-Instruct",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:8000/v1',
            'max_images': 99
        }
    elif model_type == 'vllm_phi4':
        agent_config = {
            "model_type": "microsoft/Phi-4-multimodal-instruct",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:8000/v1',
            'max_images': 99
        }
    elif model_type == 'o3-mini':
        agent_config = {
            "model_type": ModelType.O3_MINI,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == 'gpt-4.1':
        agent_config = {
            "model_type": ModelType.GPT_4_1,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == 'gpt-4.1-mini':
        agent_config = {
            "model_type": ModelType.GPT_4_1_MINI,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == '4o':
        agent_config = {
            "model_type": ModelType.GPT_4O,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
            # "model_name": '4o'
        }
    elif model_type == '4o-mini':
        agent_config = {
            "model_type": ModelType.GPT_4O_MINI,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == 'o1':
        agent_config = {
            "model_type": ModelType.O1,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
            # "model_name": 'o1'
        }
    elif model_type == 'o3':
        agent_config = {
            "model_type": ModelType.O3,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == 'gpt-5':
        agent_config = {
            "model_type": ModelType.GPT_5,
            "model_config": ChatGPTConfig().as_dict(),
            "model_platform": ModelPlatformType.OPENAI,
        }
    elif model_type == 'vllm_qwen_vl':
        agent_config = {
            "model_type": "Qwen/Qwen2.5-VL-7B-Instruct",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:7000/v1'
        }
    elif model_type == 'vllm_qwen':
        agent_config = {
            "model_type": "Qwen/Qwen2.5-7B-Instruct",
            "model_platform": ModelPlatformType.VLLM,
            "model_config": VLLMConfig().as_dict(),
            "url": 'http://localhost:8000/v1',
        }
    elif model_type == 'openrouter_qwen_72b':
        agent_config = {
            'model_type': ModelType.OPENROUTER_QWEN_2_5_72B,
            'model_platform': ModelPlatformType.OPENROUTER,
            'model_config': OpenRouterConfig().as_dict(),
        }
    elif model_type == 'openrouter_qwen_vl_72b':
        agent_config = {
            'model_type': ModelType.OPENROUTER_QWEN_2_5_VL_72B,
            'model_platform': ModelPlatformType.OPENROUTER,
            'model_config': OpenRouterConfig().as_dict(),
        }
    elif model_type == 'openrouter_qwen_vl_7b':
        agent_config = {
            'model_type': ModelType.OPENROUTER_QWEN_2_5_VL_7B,
            'model_platform': ModelPlatformType.OPENROUTER,
            'model_config': OpenRouterConfig().as_dict(),
        }
    elif model_type == 'openrouter_qwen_7b':
        agent_config = {
            'model_type': ModelType.OPENROUTER_QWEN_2_5_7B,
            'model_platform': ModelPlatformType.OPENROUTER,
            'model_config': OpenRouterConfig().as_dict(),
        }
    else:
        agent_config = {
            'model_type': model_type,
            'model_platform': ModelPlatformType.OPENAI_COMPATIBLE_MODEL,
            'model_config': None
        }
    
    return agent_config


# def match_response(response):
#     response_text = response.msgs[0].content

#     # This regular expression looks for text between ```python ... ```
#     pattern = r'```python(.*?)```'
#     match = re.search(pattern, response_text, flags=re.DOTALL)

#     if not match:
#         pattern = r'```(.*?)```'
#         match = re.search(pattern, response_text, flags=re.DOTALL)

#     if match:
#         code_snippet = match.group(1).strip()
#     else:
#         # If there's no fenced code block, fallback to entire response or handle error
#         code_snippet = response_text
#     return code_snippet

# def run_code_with_utils(code, utils_functions):
#     return run_code(utils_functions + '\n' + code)

# def run_code(code):
#     """
#     Execute Python code and capture stdout as well as the full stack trace on error.
#     Forces __name__ = "__main__" so that if __name__ == "__main__": blocks will run.
    
#     Returns:
#         (output, error)
#         - output: string containing everything that was printed to stdout
#         - error: string containing the full traceback if an exception occurred; None otherwise
#     """
#     stdout_capture = io.StringIO()
#     # Provide a globals dict specifying that __name__ is "__main__"
#     exec_globals = {"__name__": "__main__"}

#     with contextlib.redirect_stdout(stdout_capture):
#         try:
#             exec(code, exec_globals)
#             error = None
#         except Exception:
#             # Capture the entire stack trace
#             error = traceback.format_exc()

#     output = stdout_capture.getvalue()
#     return output, error


# def run_code_from_agent(agent, msg, num_retries=1):
#     agent.reset()
#     log = []
#     for attempt in range(num_retries + 1):  # +1 to include the initial attempt
#         response = agent.step(msg)
#         code = match_response(response)
#         output, error = run_code(code)
#         log.append((code, output, error))
        
#         if error is None:
#             return log
        
#         if attempt < num_retries:
#             print(f"Retrying... Attempt {attempt + 1} of {num_retries}")
#             msg = error
    
#     return log

# def run_modular(all_code, file_name, with_border=True, with_label=True):
#     concatenated_code = utils_functions
#     concatenated_code += "\n".join(all_code.values())
#     if with_border and with_label:
#         concatenated_code += add_border_label_function
#         concatenated_code += create_id_map_function
#         concatenated_code += save_helper_info_border_label.format(file_name, file_name, file_name)
#     elif with_border:
#         concatenated_code += add_border_function
#         concatenated_code += save_helper_info_border.format(file_name, file_name)
#     else:
#         concatenated_code += f'\nposter.save("{file_name}")'
#     output, error = run_code(concatenated_code)
#     return concatenated_code, output, error

# def edit_modular(
#         agent,
#         edit_section_name, 
#         feedback,
#         all_code, 
#         file_name, 
#         outline,
#         content,
#         images,
#         actor_prompt,
#         num_retries=1,
#         prompt_type='initial'
#     ):
#     agent.reset()
#     log = []
#     if prompt_type == 'initial':
#         msg = actor_prompt.format(
#             outline['meta'],
#             {edit_section_name: outline[edit_section_name]}, 
#             content, 
#             images,
#             documentation
#         )
#     elif prompt_type == 'edit':
#         assert (edit_section_name == list(feedback.keys())[0])
#         msg = actor_prompt.format(
#             edit_section_name,
#             all_code[edit_section_name],
#             feedback,
#             {edit_section_name: outline[edit_section_name]}, 
#             content, 
#             images,
#             documentation
#         )
#     elif prompt_type == 'new':
#         assert (list(feedback.keys())[0] == 'all_good')
#         msg = actor_prompt.format(
#             {edit_section_name: outline[edit_section_name]}, 
#             content, 
#             images,
#             documentation
#         )

#     for attempt in range(num_retries + 1):
#         response = agent.step(msg)
#         new_code = match_response(response)
#         all_code_changed = all_code.copy()
#         all_code_changed[edit_section_name] = new_code
#         concatenated_code, output, error = run_modular(all_code_changed, file_name, False, False)
#         log.append({
#             "code": new_code,
#             "output": output,
#             "error": error,
#             "concatenated_code": concatenated_code
#         })
#         if error is None:
#             return log
        
#         if attempt < num_retries:
#             print(f"Retrying... Attempt {attempt + 1} of {num_retries}")
#             msg = error
#             msg += '\nFix your code and try again. The poster is a single-page pptx.'
#             if prompt_type != 'initial':
#                 msg += '\nAssume that you have had a Presentation object named "poster" and a slide named "slide".'

#     return log

# def add_border_to_all_elements(prs, border_color=RGBColor(255, 0, 0), border_width=Pt(2)):
#     """
#     Iterates over all slides and shapes in the Presentation object 'prs'
#     and applies a red border with the specified width to each shape.
    
#     Args:
#         prs: The Presentation object to modify.
#         border_color: An instance of RGBColor for the border color (default is red).
#         border_width: The width of the border as a Pt value (default is 2 points).
#     """
#     for slide in prs.slides:
#         for shape in slide.shapes:
#             # Some shapes (like charts or group shapes) might not support border styling
#             try:
#                 # Set the line fill to be solid and assign the desired color and width.
#                 shape.line.fill.solid()
#                 shape.line.fill.fore_color.rgb = border_color
#                 shape.line.width = border_width
#             except Exception as e:
#                 # If a shape doesn't support setting a border, print a message and continue.
#                 print(f"Could not add border to shape {shape.shape_type}: {e}")


# # 1 point = 12700 EMUs (helper function)
# def pt_to_emu(points: float) -> int:
#     return int(points * 12700)

# def add_border_and_labels(
#     prs,
#     border_color=RGBColor(255, 0, 0),   # Red border for shapes
#     border_width=Pt(2),                # 2-point border width
#     label_outline_color=RGBColor(0, 0, 255),  # Blue outline for label circle
#     label_text_color=RGBColor(0, 0, 255),     # Blue text color
#     label_diameter_pt=40                       # Diameter of the label circle in points
# ):
#     """
#     Iterates over all slides and shapes in the Presentation 'prs', applies a 
#     red border to each shape, and places a transparent (no fill), blue-outlined 
#     circular label with a blue number in the center of each shape. Labels start 
#     from 0 and increment for every shape that gets a border.

#     Args:
#         prs: The Presentation object to modify.
#         border_color: RGBColor for the shape border color (default: red).
#         border_width: The width of the shape border (Pt).
#         label_outline_color: The outline color for the label circle (default: blue).
#         label_text_color: The color of the label text (default: blue).
#         label_diameter_pt: The diameter of the label circle, in points (default: 40).
#     """
#     label_diameter_emu = pt_to_emu(label_diameter_pt)  # convert diameter (points) to EMUs
#     label_counter = 0  # Start labeling at 0
#     labeled_elements = {}

#     for slide in prs.slides:
#         for shape in slide.shapes:
#             # Skip shapes that are labels themselves
#             if shape.name.startswith("Label_"):
#                 continue

#             try:
#                 # --- 1) Add red border to the shape (if supported) ---
#                 shape.line.fill.solid()
#                 shape.line.fill.fore_color.rgb = border_color
#                 shape.line.width = border_width

#                 # --- 2) Calculate center for the label circle ---
#                 label_left = shape.left + (shape.width // 2) - (label_diameter_emu // 2)
#                 label_top  = shape.top  + (shape.height // 2) - (label_diameter_emu // 2)

#                 # --- 3) Create label circle (an OVAL) in the center of the shape ---
#                 label_shape = slide.shapes.add_shape(
#                     MSO_AUTO_SHAPE_TYPE.OVAL,
#                     label_left,
#                     label_top,
#                     label_diameter_emu,
#                     label_diameter_emu
#                 )
#                 label_shape.name = f"Label_{label_counter}"  # so we can skip it later

#                 # **Make the circle completely transparent** (no fill at all)
#                 label_shape.fill.background()

#                 # **Give it a blue outline**
#                 label_shape.line.fill.solid()
#                 label_shape.line.fill.fore_color.rgb = label_outline_color
#                 label_shape.line.width = Pt(3)

#                 # --- 4) Add the label number (centered, blue text) ---
#                 tf = label_shape.text_frame
#                 tf.text = str(label_counter)
#                 paragraph = tf.paragraphs[0]
#                 paragraph.alignment = PP_ALIGN.CENTER

#                 run = paragraph.runs[0]
#                 font = run.font
#                 font.size = Pt(40)      # Larger font
#                 font.bold = True
#                 font.name = "Arial"
#                 font._element.get_or_change_to_solidFill()
#                 font.fill.fore_color.rgb = label_text_color
#                 # Record properties from the original shape and label text.
#                 labeled_elements[label_counter] = {
#                     'left': f'{shape.left} EMU',
#                     'top': f'{shape.top} EMU',
#                     'width': f'{shape.width} EMU',
#                     'height': f'{shape.height} EMU',
#                     'font_size': f'{shape.text_frame.font.size} PT' if hasattr(shape, 'text_frame') else None,
#                 }

#                 # --- 5) Increment label counter (so every shape has a unique label) ---
#                 label_counter += 1

#             except Exception as e:
#                 # If the shape doesn't support borders or text, skip gracefully
#                 print(f"Could not add border/label to shape (type={shape.shape_type}): {e}")

#     return labeled_elements


# def fill_content(agent, prompt, num_retries, existing_code=''):
#     if existing_code == '':
#         existing_code = utils_functions
#     agent.reset()
#     log = []
#     cumulative_input_token, cumulative_output_token = 0, 0
#     for attempt in range(num_retries + 1):
#         response = agent.step(prompt)
#         input_token, output_token = account_token(response)
#         cumulative_input_token += input_token
#         cumulative_output_token += output_token
#         new_code = match_response(response)
#         all_code = existing_code + '\n' + new_code

#         output, error = run_code(all_code)
#         log.append({
#             "code": new_code,
#             "output": output,
#             "error": error,
#             "concatenated_code": all_code,
#             'cumulative_tokens': (cumulative_input_token, cumulative_output_token)
#         })

#         if error is None:
#             return log
        
#         if attempt < num_retries:
#             print(f"Retrying... Attempt {attempt + 1} of {num_retries}")
#             prompt = error
#     return log

# def apply_theme(agent, prompt, num_retries, existing_code=''):
#     return fill_content(agent, prompt, num_retries, existing_code)

# def edit_code(agent, prompt, num_retries, existing_code=''):
#     return fill_content(agent, prompt, num_retries, existing_code)

# def stylize(agent, prompt, num_retries, existing_code=''):
#     return fill_content(agent, prompt, num_retries, existing_code)

# def gen_layout(agent, prompt, num_retries, name_to_hierarchy, visual_identifier='', existing_code=''):
#     if existing_code == '':
#         existing_code = utils_functions
#     agent.reset()
#     log = []
#     cumulative_input_token, cumulative_output_token = 0, 0
#     for attempt in range(num_retries + 1):
#         response = agent.step(prompt)
#         input_token, output_token = account_token(response)
#         cumulative_input_token += input_token
#         cumulative_output_token += output_token
#         new_code = match_response(response)
#         all_code = existing_code + '\n' + new_code

#         # Save visualizations
#         all_code += f'''
# name_to_hierarchy = {name_to_hierarchy}
# identifier = "{visual_identifier}"
# get_visual_cues(name_to_hierarchy, identifier)
# '''

#         output, error = run_code(all_code)
#         log.append({
#             "code": new_code,
#             "output": output,
#             "error": error,
#             "concatenated_code": all_code,
#             'num_tokens': (input_token, output_token),
#             'cumulative_tokens': (cumulative_input_token, cumulative_output_token)
#         })

#         if error is None:
#             return log
        
#         if attempt < num_retries:
#             print(f"Retrying... Attempt {attempt + 1} of {num_retries}")
#             prompt = error
#     return log

# def gen_layout_parallel(agent, prompt, num_retries, existing_code='', slide_width=0, slide_height=0, tmp_name='tmp'):
#     if existing_code == '':
#         existing_code = utils_functions
        
#     existing_code += f'''
# poster = create_poster(width_inch={slide_width}, height_inch={slide_height})
# slide = add_blank_slide(poster)
# save_presentation(poster, file_name="poster_{tmp_name}.pptx")
# '''
#     agent.reset()
#     log = []
#     cumulative_input_token, cumulative_output_token = 0, 0
#     for attempt in range(num_retries + 1):
#         response = agent.step(prompt)
#         input_token, output_token = account_token(response)
#         cumulative_input_token += input_token
#         cumulative_output_token += output_token
#         new_code = match_response(response)
#         all_code = existing_code + '\n' + new_code

#         output, error = run_code(all_code)
#         log.append({
#             "code": new_code,
#             "output": output,
#             "error": error,
#             "concatenated_code": all_code,
#             'num_tokens': (input_token, output_token),
#             'cumulative_tokens': (cumulative_input_token, cumulative_output_token)
#         })
#         if output is None or output == '':
#             prompt = 'No object name printed.'
#             continue

#         if error is None:
#             return log
        
#         if attempt < num_retries:
#             # print(f"Retrying... Attempt {attempt + 1} of {num_retries}", flush=True)
#             prompt = error
#     return log

# def compute_bullet_length(textbox_content):
#     total = 0
#     for bullet in textbox_content:
#         for run in bullet['runs']:
#             total += len(run['text'])
#     return total

# def check_bounding_boxes(bboxes, overall_width, overall_height):
#     """
#     Given a dictionary 'bboxes' whose keys are bounding-box names and whose values are
#     dictionaries with keys 'left', 'top', 'width', and 'height' (all floats),
#     along with the overall canvas width and height, this function checks for:

#       1) An overlap between any two bounding boxes (it returns a tuple of their names).
#       2) A bounding box that extends beyond the overall width or height (it returns a tuple
#          containing just that bounding box's name).

#     It stops upon finding the first error:
#       - If an overlap is found first, it returns (name1, name2).
#       - Otherwise, if an overflow is found, it returns (name,).
#       - If nothing is wrong, it returns ().

#     Parameters:
#         bboxes (dict): e.g. {
#             "box1": {"left": 10.0, "top": 10.0, "width": 50.0, "height": 20.0},
#             "box2": {"left": 55.0, "top": 15.0, "width": 10.0, "height": 10.0},
#             ...
#         }
#         overall_width (float): The total width of the available space.
#         overall_height (float): The total height of the available space.

#     Returns:
#         tuple: Either (box1, box2) if an overlap is found,
#                (box,) if a bounding box overflows,
#                or () if no problem is found.
#     """

#     # Convert bboxes into a list of (name, left, top, width, height) for easier iteration.
#     box_list = []
#     for name, coords in bboxes.items():
#         left = coords["left"]
#         top = coords["top"]
#         width = coords["width"]
#         height = coords["height"]
#         box_list.append((name, left, top, width, height))

#     # Helper function to check overlap between two boxes
#     def boxes_overlap(box_a, box_b):
#         # Unpack bounding-box data
#         name_a, left_a, top_a, width_a, height_a = box_a
#         name_b, left_b, top_b, width_b, height_b = box_b

#         # Compute right and bottom coordinates
#         right_a = left_a + width_a
#         bottom_a = top_a + height_a
#         right_b = left_b + width_b
#         bottom_b = top_b + height_b

#         # Rectangles overlap if not separated along either x or y axis
#         # If one box is completely to the left or right or above or below the other,
#         # there's no overlap.
#         no_overlap = (right_a <= left_b or  # A is completely left of B
#                       right_b <= left_a or  # B is completely left of A
#                       bottom_a <= top_b or  # A is completely above B
#                       bottom_b <= top_a)    # B is completely above A
#         return not no_overlap

#     # 1) Check for overlap first
#     n = len(box_list)
#     for i in range(n):
#         for j in range(i + 1, n):
#             if boxes_overlap(box_list[i], box_list[j]):
#                 return (box_list[i][0], box_list[j][0])  # Return names

#     # 2) Check for overflow
#     for name, left, top, width, height in box_list:
#         right = left + width
#         bottom = top + height

#         # If boundary is outside [0, overall_width] or [0, overall_height], it's an overflow
#         if (left < 0 or top < 0 or right > overall_width or bottom > overall_height):
#             return (name,)

#     # 3) If nothing is wrong, return empty tuple
#     return ()


# def is_poster_filled(
#     bounding_boxes: dict,
#     overall_width: float,
#     overall_height: float,
#     max_lr_margin: float,
#     max_tb_margin: float
# ) -> bool:
#     """
#     Given a dictionary of bounding boxes (keys are box names and
#     values are dicts with float keys: "left", "top", "width", "height"),
#     along with the overall dimensions of the poster and maximum allowed
#     margins, this function determines whether the boxes collectively
#     fill the poster within those margin constraints.

#     :param bounding_boxes: Dictionary of bounding boxes of the form:
#                           {
#                               "box1": {"left": float, "top": float, "width": float, "height": float},
#                               "box2": {...},
#                               ...
#                           }
#     :param overall_width: Total width of the poster
#     :param overall_height: Total height of the poster
#     :param max_lr_margin: Maximum allowed left and right margins
#     :param max_tb_margin: Maximum allowed top and bottom margins
#     :return: True if the bounding boxes fill the poster (with no big leftover spaces),
#              False otherwise.
#     """

#     # If there are no bounding boxes, we consider the poster unfilled.
#     if not bounding_boxes:
#         return False

#     # Extract the minimum left, maximum right, minimum top, and maximum bottom from all bounding boxes.
#     min_left = min(b["left"] for b in bounding_boxes.values())
#     max_right = max(b["left"] + b["width"] for b in bounding_boxes.values())
#     min_top = min(b["top"] for b in bounding_boxes.values())
#     max_bottom = max(b["top"] + b["height"] for b in bounding_boxes.values())

#     # Calculate leftover margins.
#     leftover_left = min_left
#     leftover_right = overall_width - max_right
#     leftover_top = min_top
#     leftover_bottom = overall_height - max_bottom

#     # Check if leftover margins exceed the allowed maxima.
#     if (leftover_left > max_lr_margin or leftover_right > max_lr_margin or
#         leftover_top > max_tb_margin or leftover_bottom > max_tb_margin):
#         return False

#     return True

# def check_and_fix_subsections(section, subsections):
#     """
#     Given a 'section' bounding box and a dictionary of 'subsections',
#     checks:

#     1) That each subsection is within the main section and that
#        no two subsections overlap.
#        - If there is a problem, returns a tuple of the names of
#          the offending subsections.

#     2) That the subsections fully occupy the area of 'section'.
#        - If not, greedily expand each subsection (in the order
#          left->right->top->bottom), and return a dictionary of
#          the updated bounding boxes for the subsections.

#     3) Otherwise, returns an empty tuple if nothing is wrong.

#     :param section: dict with keys "left", "top", "width", "height".
#     :param subsections: dict mapping name -> dict with "left", "top", "width", "height".
#     :return: Either
#         - tuple of subsection names that are out of bounds or overlapping,
#         - dict of expanded bounding boxes if they do not fully occupy 'section',
#         - or an empty tuple if everything is correct.
#     """

#     # --- Utility functions ---
#     def right(rect):
#         return rect["left"] + rect["width"]

#     def bottom(rect):
#         return rect["top"] + rect["height"]

#     def is_overlapping(r1, r2):
#         """
#         Returns True if rectangles r1 and r2 overlap (strictly),
#         False otherwise.
#         """
#         return not (
#             right(r1) <= r2["left"]
#             or r1["left"] >= right(r2)
#             or bottom(r1) <= r2["top"]
#             or r1["top"] >= bottom(r2)
#         )

#     # 1) Check each subsection is within the main section
#     names_violating = set()
#     sec_left, sec_top = section["left"], section["top"]
#     sec_right = section["left"] + section["width"]
#     sec_bottom = section["top"] + section["height"]

#     for name, sub in subsections.items():
#         # Check boundary
#         sub_left, sub_top = sub["left"], sub["top"]
#         sub_right, sub_bottom = right(sub), bottom(sub)
#         if (
#             sub_left < sec_left
#             or sub_top < sec_top
#             or sub_right > sec_right
#             or sub_bottom > sec_bottom
#         ):
#             # Out of bounds
#             names_violating.add(name)

#     # 2) Check pairwise overlaps
#     sub_keys = list(subsections.keys())
#     for i in range(len(sub_keys)):
#         for j in range(i + 1, len(sub_keys)):
#             n1, n2 = sub_keys[i], sub_keys[j]
#             if is_overlapping(subsections[n1], subsections[n2]):
#                 # Mark both as violating
#                 names_violating.add(n1)
#                 names_violating.add(n2)

#     # If anything violated boundaries or overlapped, return them as a tuple
#     if names_violating:
#         return tuple(sorted(names_violating))

#     # 3) Check if subsections fully occupy the section by area.
#     #    (Since we've checked there's no overlap, area-based check is safe for "full coverage".)
#     area_section = section["width"] * section["height"]
#     area_subs = sum(
#         sub["width"] * sub["height"] for sub in subsections.values()
#     )

#     if area_subs < area_section:
#         # -- We need to expand subsections greedily. --

#         # Make a copy of the bounding boxes so as not to modify originals.
#         expanded_subs = {
#             name: {
#                 "left": sub["left"],
#                 "top": sub["top"],
#                 "width": sub["width"],
#                 "height": sub["height"],
#             }
#             for name, sub in subsections.items()
#         }

#         # Helper to see whether we are touching a boundary or another subsection
#         def touching_left(sname, sbox):
#             if abs(sbox["left"] - sec_left) < 1e-9:
#                 # touches main section left boundary
#                 return True
#             # touches the right edge of another subsection
#             for oname, obox in expanded_subs.items():
#                 if oname == sname:
#                     continue
#                 if abs(right(obox) - sbox["left"]) < 1e-9:
#                     return True
#             return False

#         def touching_right(sname, sbox):
#             r = right(sbox)
#             if abs(r - sec_right) < 1e-9:
#                 return True
#             for oname, obox in expanded_subs.items():
#                 if oname == sname:
#                     continue
#                 if abs(obox["left"] - r) < 1e-9:
#                     return True
#             return False

#         def touching_top(sname, sbox):
#             if abs(sbox["top"] - sec_top) < 1e-9:
#                 return True
#             for oname, obox in expanded_subs.items():
#                 if oname == sname:
#                     continue
#                 if abs(bottom(obox) - sbox["top"]) < 1e-9:
#                     return True
#             return False

#         def touching_bottom(sname, sbox):
#             b = bottom(sbox)
#             if abs(b - sec_bottom) < 1e-9:
#                 return True
#             for oname, obox in expanded_subs.items():
#                 if oname == sname:
#                     continue
#                 if abs(obox["top"] - b) < 1e-9:
#                     return True
#             return False

#         # Attempt a single pass of expansions, left->right->top->bottom
#         for name in expanded_subs:
#             sub = expanded_subs[name]

#             # Expand left if not touching left boundary or another box
#             if not touching_left(name, sub):
#                 # The "left boundary" is the maximum "right" of any subsection strictly to the left,
#                 # or the section's left boundary, whichever is larger.
#                 left_bound = sec_left
#                 for oname, obox in expanded_subs.items():
#                     if oname == name:
#                         continue
#                     r_ = obox["left"] + obox["width"]
#                     # only consider those that are strictly left of this sub
#                     if r_ <= sub["left"] and r_ > left_bound:
#                         left_bound = r_
#                 # Now expand
#                 delta = sub["left"] - left_bound
#                 if delta > 1e-9:  # If there's any real gap
#                     sub["width"] += delta
#                     sub["left"] = left_bound

#             # Expand right if not touching right boundary or another box
#             if not touching_right(name, sub):
#                 right_bound = sec_right
#                 sub_right = sub["left"] + sub["width"]
#                 for oname, obox in expanded_subs.items():
#                     if oname == name:
#                         continue
#                     left_ = obox["left"]
#                     # only consider those that are strictly to the right
#                     if left_ >= sub_right and left_ < right_bound:
#                         right_bound = left_
#                 delta = right_bound - (sub["left"] + sub["width"])
#                 if delta > 1e-9:
#                     sub["width"] += delta

#             # Expand top if not touching top boundary or another box
#             if not touching_top(name, sub):
#                 top_bound = sec_top
#                 for oname, obox in expanded_subs.items():
#                     if oname == name:
#                         continue
#                     b_ = obox["top"] + obox["height"]
#                     if b_ <= sub["top"] and b_ > top_bound:
#                         top_bound = b_
#                 delta = sub["top"] - top_bound
#                 if delta > 1e-9:
#                     sub["height"] += delta
#                     sub["top"] = top_bound

#             # Expand bottom if not touching bottom boundary or another box
#             if not touching_bottom(name, sub):
#                 bottom_bound = sec_bottom
#                 sub_bottom = sub["top"] + sub["height"]
#                 for oname, obox in expanded_subs.items():
#                     if oname == name:
#                         continue
#                     other_top = obox["top"]
#                     if other_top >= sub_bottom and other_top < bottom_bound:
#                         bottom_bound = other_top
#                 delta = bottom_bound - (sub["top"] + sub["height"])
#                 if delta > 1e-9:
#                     sub["height"] += delta

#         # After expansion, return the expanded dictionary
#         # per the spec: "If the second case happens, return a dictionary ...
#         # containing the modified bounding box dictionaries."
#         return expanded_subs

#     # If we get here, then area_subs == area_section and there's no overlap => all good
#     return ()

# async def rendered_dims(html: Path) -> tuple[int, int]:
#     async with async_playwright() as p:
#         browser = await p.chromium.launch()
#         page    = await browser.new_page()        # no fixed viewport yet
#         resolved = html.resolve()
#         # quote_from_bytes expects bytes, so we encode the path as UTF‐8:
#         url = "file://" + quote_from_bytes(str(resolved).encode("utf-8"), safe="/:")
#         await page.goto(url, wait_until="networkidle")

#         # 1) bounding-box of <body>
#         body_box = await page.eval_on_selector(
#             "body",
#             "el => el.getBoundingClientRect()")
#         w = int(body_box["width"])
#         h = int(body_box["height"])

#         await browser.close()
#         return w, h

    
# def html_to_png(html_abs_path, poster_width_default, poster_height_default, output_path):
#     html_file = html_abs_path

#     try:
#         w, h = asyncio.run(rendered_dims(html_file))
#     except:
#         w = poster_width_default
#         h = poster_height_default

#     with sync_playwright() as p:
#         path_posix = Path(html_file).resolve().as_posix()

#         file_url = "file://" + quote(path_posix, safe="/:")
#         browser = p.chromium.launch()
#         page    = browser.new_page(viewport={"width": w, "height": h})
#         page.goto(file_url, wait_until='networkidle')
#         page.screenshot(path=output_path, full_page=True)
#         browser.close()

# def account_token(response):
#     input_token = response.info['usage']['prompt_tokens']
#     output_token = response.info['usage']['completion_tokens']

#     return input_token, output_token

# def style_bullet_content(bullet_content_item, color, fill_color):
#     for i in range(len(bullet_content_item)):
#         bullet_content_item[i]['runs'][0]['color'] = color
#         bullet_content_item[i]['runs'][0]['fill_color'] = fill_color

def scale_to_target_area(width, height, target_width=900, target_height=1200):
    """
    Scale the given width and height by the same factor to achieve a new area equal 
    to target_width * target_height while preserving the aspect ratio.

    Parameters:
      width (float or int): The original width.
      height (float or int): The original height.
      target_width (int, optional): The target width for area calculation. Default is 900.
      target_height (int, optional): The target height for area calculation. Default is 1200.

    Returns:
      tuple: (new_width, new_height) after scaling such that the area is target_width * target_height.
    """
    # Calculate target area from provided dimensions.
    target_area = target_width * target_height
    
    # Calculate original area
    current_area = width * height
    
    # Compute scale factor required: s^2 * (width * height) = target_area => s = sqrt(target_area / (width * height))
    scale_factor = math.sqrt(target_area / current_area)
    
    # Calculate new dimensions
    new_width = width * scale_factor
    new_height = height * scale_factor
    
    # Optional: Round the dimensions to integers.
    return int(round(new_width)), int(round(new_height))

# def char_capacity(
#     bbox,
#     font_size_px=40 * (96 / 72),  # Default font size in px (40pt converted to px)
#     *,
#     # Average glyph width as fraction of font-size (≈0.6 for monospace,
#     # ≈0.52–0.55 for most proportional sans-serif faces)
#     avg_width_ratio: float = 0.54,
#     line_height_ratio: float = 1,
#     # Optional inner padding in px that the renderer might reserve
#     padding_px: int = 0,
# ) -> int:
#     """
#     Estimate the number of characters that will fit into a rectangular text box.

#     Parameters
#     ----------
#     bbox : (x, y, height, width)  # all in pixels
#     font_size_px : int           # font size in px
#     avg_width_ratio : float      # average char width ÷ fontSize
#     line_height_ratio : float    # line height ÷ fontSize
#     padding_px : int             # optional inner padding on each side

#     Returns
#     -------
#     int : estimated character capacity
#     """
#     CHAR_CONST = 10
#     _, _, height_px, width_px = bbox

#     usable_w = max(0, width_px - 2 * padding_px)
#     usable_h = max(0, height_px - 2 * padding_px)

#     if usable_w == 0 or usable_h == 0:
#         return 0  # box is too small

#     avg_char_w = font_size_px * avg_width_ratio
#     line_height = font_size_px * line_height_ratio

#     chars_per_line = max(1, math.floor(usable_w / avg_char_w))
#     lines = max(1, math.floor(usable_h / line_height))

#     return chars_per_line * lines * CHAR_CONST

# def estimate_characters(width_in_inches, height_in_inches, font_size_points, line_spacing_points=None):
#     """
#     Estimate the number of characters that can fit into a bounding box.

#     :param width_in_inches:  The width of the bounding box, in inches.
#     :param height_in_inches: The height of the bounding box, in inches.
#     :param font_size_points: The font size, in points.
#     :param line_spacing_points: (Optional) The line spacing, in points.
#                                 Defaults to 1.5 × font_size_points if not provided.
#     :return: Estimated number of characters that fit in the bounding box.
#     """
#     if line_spacing_points is None:
#         # Default line spacing is 1.5 times the font size
#         line_spacing_points = 1.5 * font_size_points

#     # 1 inch = 72 points 
#     width_in_points = width_in_inches * 72
#     height_in_points = height_in_inches * 72

#     # Rough approximation of the average width of a character: half of the font size
#     avg_char_width = 0.5 * font_size_points

#     # Number of characters that can fit per line
#     chars_per_line = int(width_in_points // avg_char_width)

#     # Number of lines that can fit in the bounding box
#     lines_count = int(height_in_points // line_spacing_points)

#     # Total number of characters
#     total_characters = chars_per_line * lines_count

#     return total_characters

# def equivalent_length_with_forced_breaks(text, width_in_inches, font_size_points):
#     """
#     Returns the "width-equivalent length" of the text when forced newlines
#     are respected. Each physical line (including partial) is counted as if it
#     had 'max_chars_per_line' characters.
    
#     This number can exceed len(text), because forced newlines waste leftover
#     space on the line.
#     """
#     # 1 inch = 72 points
#     width_in_points = width_in_inches * 72
#     avg_char_width = 0.5 * font_size_points

#     # How many characters fit in one fully occupied line?
#     max_chars_per_line = int(width_in_points // avg_char_width)

#     # Split on explicit newlines
#     logical_lines = text.split('\n')

#     total_equiv_length = 0

#     for line in logical_lines:
#         # If the line is empty, we still "use" one line (which is max_chars_per_line slots).
#         if not line:
#             total_equiv_length += max_chars_per_line
#             continue

#         line_length = len(line)
#         # How many sub-lines (wraps) does it need?
#         sub_lines = math.ceil(line_length / max_chars_per_line)

#         # Each sub-line is effectively counted as if it were fully used
#         total_equiv_length += sub_lines * max_chars_per_line

#     return total_equiv_length

# def actual_rendered_length(
#     text,
#     width_in_inches,
#     height_in_inches,
#     font_size_points,
#     line_spacing_points=None
# ):
#     """
#     Estimate how many characters from `text` will actually fit in the bounding
#     box, taking into account explicit newlines.
#     """
#     if line_spacing_points is None:
#         line_spacing_points = 1.5 * font_size_points

#     # 1 inch = 72 points
#     width_in_points = width_in_inches * 72
#     height_in_points = height_in_inches * 72

#     # Estimate average character width
#     avg_char_width = 0.5 * font_size_points

#     # Maximum chars per line (approx)
#     max_chars_per_line = int(width_in_points // avg_char_width)

#     # Maximum number of lines that can fit
#     max_lines = int(height_in_points // line_spacing_points)

#     # Split on newline chars to get individual "logical" lines
#     logical_lines = text.split('\n')

#     used_lines = 0
#     displayed_chars = 0

#     for line in logical_lines:
#         # If the line is empty, it still takes one printed line
#         if not line:
#             used_lines += 1
#             # Stop if we exceed available lines
#             if used_lines >= max_lines:
#                 break
#             continue

#         # Number of sub-lines the text will occupy if it wraps
#         sub_lines = math.ceil(len(line) / max_chars_per_line)

#         # If we don't exceed the bounding box's vertical capacity
#         if used_lines + sub_lines <= max_lines:
#             # All chars fit within the bounding box
#             displayed_chars += len(line)
#             used_lines += sub_lines
#         else:
#             # Only part of this line will fit
#             lines_left = max_lines - used_lines
#             if lines_left <= 0:
#                 # No space left at all
#                 break

#             # We can render only `lines_left` sub-lines of this line
#             # That means we can render up to:
#             chars_that_fit = lines_left * max_chars_per_line

#             # Clip to the actual number of characters
#             chars_that_fit = min(chars_that_fit, len(line))

#             displayed_chars += chars_that_fit
#             used_lines += lines_left  # We've used up all remaining lines
#             break  # No more space in the bounding box

#     return displayed_chars


# def remove_hierarchy_and_id(data):
#     """
#     Recursively remove the 'hierarchy' and 'id' fields from a nested
#     dictionary representing sections and subsections.
#     """
#     if isinstance(data, dict):
#         # Create a new dict to store filtered data
#         new_data = {}
#         for key, value in data.items():
#             # Skip the keys "hierarchy" and "id"
#             if key in ("hierarchy", "id", 'location'):
#                 continue
#             # Recursively process the value
#             new_data[key] = remove_hierarchy_and_id(value)
#         return new_data
#     elif isinstance(data, list):
#         # If it's a list, process each item recursively
#         return [remove_hierarchy_and_id(item) for item in data]
#     else:
#         # Base case: if it's neither dict nor list, just return the value as is
#         return data
    
# def outline_estimate_num_chars(outline):
#     for k, v in outline.items():
#         if k == 'meta':
#             continue
#         if 'title' in k.lower() or 'author' in k.lower() or 'reference' in k.lower():
#             continue
#         if not 'subsections' in v:
#             num_chars = estimate_characters(
#                 v['location']['width'], 
#                 v['location']['height'], 
#                 60, line_spacing_points=None
#             )
#             v['num_chars'] = num_chars
#         else:
#             for k_sub, v_sub in v['subsections'].items():
#                 if 'title' in k_sub.lower():
#                     continue
#                 if 'path' in v_sub:
#                     continue
#                 num_chars = estimate_characters(
#                     v_sub['location']['width'], 
#                     v_sub['location']['height'], 
#                     60, line_spacing_points=None
#                 )
#                 v_sub['num_chars'] = num_chars

# def generate_length_suggestions(result_json, original_section_outline, raw_section_outline):
#     NOT_CHANGE = 'Do not change text.'
#     original_section_outline = json.loads(original_section_outline)
#     suggestion_flag = False
#     new_section_outline = copy.deepcopy(result_json)
#     def check_length(text, target, width, height):
#         text_length = equivalent_length_with_forced_breaks(
#             text,
#             width,
#             font_size_points=60,
#         )
#         if text_length - target > 100:
#             return f'Text too long, shrink by {text_length - target} characters.'
#         elif target - text_length > 100:
#             return f'Text too short, expand by {target - text_length} characters.'
#         else:
#             return NOT_CHANGE

#     if 'num_chars' in original_section_outline:
#         new_section_outline['suggestions'] = check_length(
#             result_json['description'], 
#             original_section_outline['num_chars'],
#             raw_section_outline['location']['width'],
#             raw_section_outline['location']['height']
#         )
#         if new_section_outline['suggestions'] != NOT_CHANGE:
#             suggestion_flag = True
#     if 'subsections' in original_section_outline:
#         for k, v in original_section_outline['subsections'].items():
#             if 'num_chars' in v:
#                 new_section_outline['subsections'][k]['suggestion'] = check_length(
#                     result_json['subsections'][k]['description'], 
#                     v['num_chars'],
#                     raw_section_outline['subsections'][k]['location']['width'],
#                     raw_section_outline['subsections'][k]['location']['height']
#                 )
#                 if new_section_outline['subsections'][k]['suggestion'] != NOT_CHANGE:
#                     suggestion_flag = True

#     return new_section_outline, suggestion_flag

# def get_img_ratio(img_path):
#     img = Image.open(img_path)
#     return {
#         'width': img.width,
#         'height': img.height
#     }

# def get_img_ratio_in_section(content_json):
#     res = {}
#     if 'path' in content_json:
#         res[content_json['path']] = get_img_ratio(content_json['path'])

#     if 'subsections' in content_json:
#         for subsection_name, val in content_json['subsections'].items():
#             if 'path' in val:
#                 res[val['path']] = get_img_ratio(val['path'])

#     return res


# def get_snapshot_from_section(leaf_section, section_name, name_to_hierarchy, leaf_name, section_code, empty_poster_path='poster.pptx'):
#     hierarchy = name_to_hierarchy[leaf_name]
#     hierarchy_overflow_name = f'tmp/overflow_check_<{section_name}>_<{leaf_section}>_hierarchy_{hierarchy}'
#     run_code_with_utils(section_code, utils_functions)
#     poster = Presentation(empty_poster_path)
#     # add border regardless of the hierarchy
#     curr_location = add_border_hierarchy(
#         poster, 
#         name_to_hierarchy, 
#         hierarchy, 
#         border_width=10,
#         # regardless=True
#     )
#     if not leaf_section in curr_location:
#         leaf_section = section_name
#     save_presentation(poster, file_name=f"{hierarchy_overflow_name}.pptx")
#     ppt_to_images(
#         f"{hierarchy_overflow_name}.pptx", 
#         hierarchy_overflow_name, 
#         dpi=200
#     )
#     poster_image_path = os.path.join(f"{hierarchy_overflow_name}", "slide_0001.jpg")
#     poster_image = Image.open(poster_image_path)

#     poster_width = emu_to_inches(poster.slide_width)
#     poster_height = emu_to_inches(poster.slide_height)
#     locations = convert_pptx_bboxes_json_to_image_json(
#         curr_location, 
#         poster_width, 
#         poster_height
#     )
#     zoomed_in_img = zoom_in_image_by_bbox(
#         poster_image, 
#         locations[leaf_name], 
#         padding=0.01
#     )
#     # save the zoomed_in_img
#     zoomed_in_img.save(f"{hierarchy_overflow_name}_zoomed_in.jpg")
#     return curr_location, zoomed_in_img, f"{hierarchy_overflow_name}_zoomed_in.jpg"